/*
 * Copyright 2009 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.beepmystuff;

import android.net.Uri;

import com.google.beepmystuff.BmsClient.ShareWith;

import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

import java.io.IOException;
import java.io.StringReader;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;

/**
 * Implements the low-level Beep My Stuff HTTP API, documented at
 * http://www.beepmystuff.com/static/apidocs/index.html.
 */
public class DefaultBmsLowLevelClient implements BmsLowLevelClient {
  private static final String HOST_NAME = "www.beepmystuff.com";

  private final BmsRawHttpApiClient client;
  private final String developerKey;

  /**
   * Constructs a class to talk to the BeepMyStuff servers, with the supplied
   * HTTP Client and developer key.
   */
  public DefaultBmsLowLevelClient(BmsHttpClient httpClient, String developerKey) {
    this.client = new BmsRawHttpApiClient(httpClient);
    this.developerKey = developerKey;
  }

  /**
   * Internal class to parse the results of a login RPC.
   */
  private static class LoginResultImpl implements BmsClient.LoginResult {
    private final String sessionToken;

    public LoginResultImpl(Document document) {
      sessionToken = BmsRawHttpApiClient.getTextContent(document, "login", "session");
    }

    @Override
    public String getSessionToken() {
      return sessionToken;
    }
  }

  @Override
  public BmsClient.LoginResult login(String nickname, String password)
      throws BmsTransportException, BmsApiException {
    return new LoginResultImpl(client.login(developerKey, nickname, password));
  }

  /**
   * Internal class to parse the results of an addean RPC.
   */
  private static class AddEanResultImpl implements BmsClient.AddEanResult {
    public final String message;
    public final String errorMessage;
    public final String imageUrl;
    public final String title;

    public AddEanResultImpl(Document document) {
      message = BmsRawHttpApiClient.getTextContent(document, "addean", "msg");
      errorMessage = BmsRawHttpApiClient.getTextContent(document, "addean", "error_msg");
      imageUrl = BmsRawHttpApiClient.getTextContent(document, "addean", "imgurl");
      title = BmsRawHttpApiClient.getTextContent(document, "addean", "title");
    }

    @Override
    public String getErrorMessage() {
      return errorMessage;
    }

    @Override
    public String getMessage() {
      return message;
    }

    @Override
    public String getImageUrl() {
      return imageUrl;
    }

    @Override
    public String getTitle() {
      return title;
    }
  }

  @Override
  public BmsClient.AddEanResult addEan(String session, String ean, ShareWith shareWith)
      throws BmsTransportException, BmsApiException {
    return new AddEanResultImpl(client.addEan(developerKey, session, ean,
        shareWith == ShareWith.PUBLIC, shareWith != ShareWith.PRIVATE));
  }

  @Override
  public String getAvatarPath(String session)
      throws BmsTransportException, BmsApiException {
    Document document = client.getAvatarPath(developerKey, session);
    return BmsRawHttpApiClient.getTextContent(document, "avatar", "path");
  }

  /**
   * The raw HTTP implementation of the BMS API.
   *
   * Makes requests and returns {@link Document}s containing the response, or
   * throws exceptions if the request could not be performed for any reason.
   */
  // VisibleForTesting
  static class BmsRawHttpApiClient {
    private final DocumentBuilder documentBuilder;
    private final BmsHttpClient httpClient;

    BmsRawHttpApiClient(BmsHttpClient httpClient) {
      this.httpClient = httpClient;
      DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
      dbf.setIgnoringComments(true);
      try {
        documentBuilder = dbf.newDocumentBuilder();
      } catch (ParserConfigurationException e) {
        throw new AssertionError("Couldn't construct a default DocumentBuilder");
      }
    }

    /**
     * Constructs a BeepMyStuff RPC request from the given verb and parameters.
     *
     * @param verb action verb, e.g. "login" or "addean".
     * @param repeatable whether the request should be repeated on error (a GET)
     *     or not (a POST).
     * @param params a Map of parameters to supply.
     * @return the HTTP request to use.
     */
    private HttpUriRequest createRequest(String verb, boolean repeatable,
        Map<String, String> params) {
      Uri.Builder builder = new Uri.Builder()
          .scheme("http")
          .authority(HOST_NAME)
          .path("api/" + verb);
      for (Map.Entry<String, String> param : params.entrySet()) {
        builder.appendQueryParameter(param.getKey(), param.getValue());
      }

      return (repeatable) ? new HttpGet(builder.toString()) : new HttpPost(builder.toString());
    }

    /**
     * Extract the text content from an element in a Document given a path to
     * walk.
     *
     * e.g. given a valid XHTML document, passing in a path of "head", "title"
     * would return the document's title, or throw an exception if the document
     * had no title.  If the document had an explicit blank title, would return
     * the empty string.
     *
     * Taking the same example document, a path of "body", "h1" would return the
     * value of an H1 element, or throw if the document had none, or more than
     * one.  A path of "body" would also throw , since there are other elements
     * beneath BODY (i.e. it's a mixed-content node).
     *
     * @param document the document.
     * @param path the element names to walk in order, excluding the root
     *     element.
     * @return the element's text content, or a blank string if the element is
     *     present but empty (or has no content).  Never returns null.
     * @throws BmsTransportException if the element is not present, is present
     *     more than once, or has mixed content.
     */
    // VisibleForTesting
    static String getTextContent(Document document, String... path)
        throws BmsTransportException {
      Element element = document.getDocumentElement();
      for (String elementName : path) {
        Element childElement = null;
        NodeList childNodes = element.getChildNodes();
        for (int i = 0; i < childNodes.getLength(); i++) {
          Node childNode = childNodes.item(i);
          if (childNode.getNodeType() == Node.ELEMENT_NODE
              && childNode.getNodeName().equals(elementName)) {
            if (childElement != null) {
              throw new BmsClientException(String.format(
                  "Document has more than one '%s' child in path %s",
                  elementName, Arrays.toString(path)));
            }
            childElement = (Element) childNode;
          }
        }

        if (childElement == null) {
          throw new BmsClientException(String.format(
              "Document has no '%s' child in path %s",
              elementName, Arrays.toString(path)));
        }
        element = childElement;
      }

      // We have a target element now.  Check that it's not mixed content,
      // and return the child #text node, or nodes.  (While the XML parser
      // we're using ignores comments, it won't combine #text (or CDATA) nodes
      // separated by e.g. a comment.)
      NodeList childNodes = element.getChildNodes();
      if (childNodes.getLength() == 1) {
        // Optimise for the common case.
        Node childNode = childNodes.item(0);
        if (childNode.getNodeType() == Node.TEXT_NODE) {
          return childNode.getNodeValue();
        }
        // Otherwise, we'll handle using the more general code below.
      }
      StringBuilder sb = new StringBuilder();
      for (int i = 0; i < childNodes.getLength(); i++) {
        Node childNode = childNodes.item(i);
        switch (childNode.getNodeType()) {
          case Node.TEXT_NODE:
          case Node.CDATA_SECTION_NODE:
            sb.append(childNode.getNodeValue());
            break;

          default:
            // This unfortunately also catches character entity references,
            // which the Android parser seems determined not to replace :-/.
            throw new BmsClientException(
                String.format("Mixed-content element (type %s) at path %s",
                    childNode.getNodeType(), Arrays.toString(path)));
        }
      }

      return sb.toString();
    }

    /**
     * Internal function to make an RPC request.  Note that the request can be
     * made successfully (i.e. this method returns without throwing) even though
     * the request itself failed (i.e. an application-level error was set in the
     * response).
     *
     * @param verb the action to perform on the BeepMyStuff server (e.g. "login")
     * @param repeatable whether the action is idempotent and so can be repeated
     *     in the case of a transient (i.e. I/O) error.
     * @param params a map of parameters to supply to the server
     * @return the XML document returned by the server.
     *
     * @throws BmsTransportException if the request could not be made, or if the
     *     response was unexpected or unintelligible.
     * @throws BmsApiException if an application-level error occurred.
     */
    // VisibleForTesting
    Document makeRpc(String verb, boolean repeatable, Map<String, String> params)
        throws BmsTransportException, BmsApiException {
      HttpUriRequest request = createRequest(verb, repeatable, params);
      Document document = parse(httpClient.getResponseBodyAsString(request));

      // Extract the errorcode and message.
      String errorCodeString = getTextContent(document, "error", "errorcode");
      BmsApiException.ErrorCode errorCode;
      try {
        errorCode = BmsApiException.ErrorCode.valueOf(Integer.parseInt(errorCodeString));
      } catch (NumberFormatException e) {
        throw new BmsClientException(
            String.format("Error parsing result: errorcode '%s' unparsable", errorCodeString));
      }
      if (errorCode == null) {
        throw new BmsClientException(
            String.format("Error parsing result: errorcode %s unknown", errorCodeString));
      }

      if (errorCode != BmsApiException.ErrorCode.SUCCESS) {
        throw new BmsApiException(errorCode, getTextContent(document, "error", "string"));
      }

      return document;
    }

    /**
     * Parse an XML string and return the Document.
     *
     * @throws BmsTransportException if the string is unparseable.
     */
    // VisibleForTesting
    Document parse(String response) throws AssertionError {
      Document document;
      try {
        document = documentBuilder.parse(new InputSource(new StringReader(response)));
      } catch (IOException e) {
        throw new AssertionError("IOException parsing an in-memory XML string?");
      } catch (SAXException e) {
        throw new BmsClientException("Error parsing result: " + e.getMessage(), e);
      }

      // Plain text is perfectly valid XML - it's a #text node.
      // We don't want that, though, since it's confusing and wrong.
      if (document.getDocumentElement() == null) {
        throw new BmsClientException("Error parsing result: no document element");
      }

      return document;
    }

    /**
     * Log a user in to to Beep My Stuff to get a valid session. The session
     * code returned by this call is needed for all authenticated API calls.
     *
     * @param apikey the application unique API Key
     * @param nickname the nickname of the user who wants to login
     * @param password the API password for the user
     * @return the XML document returned by the server.
     *
     * @throws BmsTransportException if the request could not be made, or if the
     *     response was unexpected or unintelligible.
     * @throws BmsApiException if an application-level error occurred.
     */
    public Document login(String apikey, String nickname, String password)
        throws BmsTransportException, BmsApiException {
      Map<String, String> params = new LinkedHashMap<String, String>();
      params.put("apikey", apikey);
      params.put("username", nickname);
      params.put("password", password);
      return makeRpc("login", true, params);
    }

    /**
     * Add an EAN to the user's library.
     *
     * @param apikey the application unique API Key
     * @param session the session ID returned by the login call
     * @param ean a valid EAN or UPC
     * @param isPublic should the item be publicly visible in the user's library
     * @param isShared should the item be visible to the user's friends.
     * @return the XML document returned by the server.
     *
     * @throws BmsTransportException if the request could not be made, or if the
     *     response was unexpected or unintelligible.
     * @throws BmsApiException if an application-level error occurred.
     */
    public Document addEan(String apikey, String session, String ean,
        boolean isPublic, boolean isShared)
        throws BmsTransportException, BmsApiException {
      Map<String, String> params = new LinkedHashMap<String, String>();
      params.put("apikey", apikey);
      params.put("session", session);
      params.put("ean", ean);
      params.put("public", isPublic ? "true" : "false");
      params.put("share", isShared ? "true" : "false");
      return makeRpc("addean", false, params);
    }

    /**
     * Get the path to the user's avatar icon.
     *
     * @param apikey the application unique API Key
     * @param session the session ID returned by the login call
     * @return the XML document returned by the server.
     *
     * @throws BmsTransportException if the request could not be made, or if the
     *     response was unexpected or unintelligible.
     * @throws BmsApiException if an application-level error occurred.
     */
    public Document getAvatarPath(String apikey, String session)
        throws BmsTransportException, BmsApiException {
      Map<String, String> params = new LinkedHashMap<String, String>();
      params.put("apikey", apikey);
      params.put("session", session);
      return makeRpc("getavatarpath", true, params);
    }
  }
}
